<?php

/** 
 * @Author: 李红雨 - RainLee <rainlee1990@yeah.net>
 * @Date: 2022-06-21 22:07:20
 * @LastEditors: 李红雨 - RainLee <rainlee1990@yeah.net>
 * @LastEditTime: 2022-07-16 14:00:56
 * @Description: File Description
 */

namespace rainlee\authn\jwt;

use Firebase\JWT\ExpiredException;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use rainlee\authn\exception\UnauthorizedException;
use rainlee\authn\jwt\exception\AlgorithmException;
use rainlee\authn\jwt\exception\RefreshExpiredException;
use think\facade\Cache;
use think\facade\Config;
use think\facade\Request;

class JwtHandler
{
    protected $config = [
        'signer_key'   => null, // 密钥，加密算法为 RS256 时 参数为数组 [私钥文件，公钥文件]
        'effective_at' => 0, // 生成后多久生效，默认生成后直接使用
        'expires_at'   => 7200, // Token有效期（秒
        'refresh_ttL'  => 0, // 刷新时间
        'signer'       => 'HS256', // 加密算法可选值 HS256, RS256
        'token_mode'   => ['header', 'cookie', 'param'], // 获取 Token 方式，数组靠前值优先
        'iss'          => '',
        'aud'          => '',
        'payload'      => ['id'] // jwt荷载(Payload)部分存入的数据字段名
    ];

    protected $guard;

    // 解析后的 payload 数组
    protected $payload = null;

    public function __construct($guard, $name)
    {
        $this->guard  = $guard;
        $this->resolveConfig($name);
    }

    /**
     * 解析配置文件
     * 
     * @return JwtConfig
     */
    protected function resolveConfig($name)
    {
        if ($config = Config::get('authn.jwts.' . $name)) {
            $this->config = array_merge($this->config, $config);
        }
    }

    public function getPayloadKeys()
    {
        return $this->config['payload'];
    }

    /**
     * 生成 payload
     * @param array $claims
     * @return array
     */
    protected function buildPayload($claims = [])
    {

        $jti = $claims['jti'] ?? $this->buildJti();

        $claims = array_filter($claims, function ($key) {
            return !in_array($key, ['iss', 'aud', 'iat', 'nbf', 'exp', 'rat', 'jti']);
        }, ARRAY_FILTER_USE_KEY);

        return [
            'iss'  => $this->config['iss'],
            'aud'  => $this->config['aud'],
            'iat'  => time(),
            'nbf'  => time() + $this->config['effective_at'],
            'exp'  => time() + $this->config['expires_at'],
            'rat'  => time() + $this->config['expires_at'] + $this->config['refresh_ttL'],
            'jti'  => $jti,
            'data' => $claims
        ];
    }

    /**
     * 生成 jti
     */
    protected function buildJti()
    {
        return md5($this->guard . '_' . uniqid());
    }

    protected function getSignerKey($operation = 'encode')
    {
        $signer_key = null;
        switch ($this->config['signer']) {
            case 'HS256':
                $signer_key = $this->config['signer_key'];
                break;
            case 'RS256':
                $signer_key = $this->config['signer_key'][$operation == 'encode' ? 0 : 1];
                break;
            default:
                throw new AlgorithmException('Algorithm not supported');
                break;
        }
        return $signer_key;
    }

    /**
     * 生成Token
     * @param array $claims
     * @return Token
     */
    public function build($claims = [])
    {
        $payload = $this->buildPayload($claims);
        return JWT::encode($payload, $this->getSignerKey('encode'), $this->config['signer']);
    }

    /**
     * 验证Token
     * 
     * @param string|null $token;
     * @return bool
     */
    public function validation($token = null)
    {
        $tokenStr = $token ?? $this->getToken();
        if (is_null($token) && !empty($this->payload)) {
            return true;
        }

        $signer_key = $this->getSignerKey('decode');

        try {
            JWT::$leeway = $this->config['refresh_ttL']; // 添加刷新有效期的回旋时间，让 token 过期后在刷新时间内依然可以解析
            $this->payload = JWT::decode($tokenStr, new Key($signer_key, $this->config['signer']));
        } catch (ExpiredException $e) {
            // 刷新回旋时间过期返回未登录异常
            throw new RefreshExpiredException('The refresh time has expired');
        } catch (\Exception $e) {
            throw new UnauthorizedException('Unauthorized', 401);
        }

        if (Cache::has($this->guard . '_blacklist_' . $this->payload->jti)) {
            $this->payload = null;
            throw new UnauthorizedException('Unauthorized', 401);
        }

        // 判断 token 是否过期
        if (isset($this->payload->exp) && time() >= $this->payload->exp) {
            throw new ExpiredException('Expired token');
        }

        return true;
    }

    /**
     * 获取荷载数据
     */
    public function getPayload()
    {
        return $this->payload;
    }

    /**
     * 刷新 Token
     * 
     * @param string $token
     * @return string
     */
    public function refresh($token = null)
    {
        if (!is_null($token)) {
            $this->validation($token);
        } else if (is_null($this->payload)) {
            $this->validation();
        }
        return $this->build($this->getPayload()->data);
    }

    /**
     * 黑名单
     */
    public function blacklist()
    {
        if (is_null($this->payload)) {
            throw new UnauthorizedException('Unauthorized', 401);
        }
        $exp = $this->payload->exp + $this->config['refresh_ttL'];
        if ($exp > time()) {
            Cache::set($this->guard . '_blacklist_' . $this->payload->jti, '', $exp - time());
        }
        return true;
    }

    /**
     * 获取Token
     */
    protected function getToken()
    {
        $token_mode = $this->config['token_mode'];
        foreach ($token_mode as $handle) {
            switch (strtolower($handle)) {
                case 'header':
                    $authorization = Request::header('authorization', '');
                    if (strpos($authorization, 'Bearer ') === 0) {
                        return substr($authorization, 7);
                    } else {
                        return $authorization;
                    }
                    break;
                case 'cookie':
                    return Request::cookie('token', '');
                    break;
                case 'param':
                    return Request::get('token', '');
                    break;
                default:
                    return '';
            }
        }
    }
}
